import _ from 'lodash'
import os from 'os'
import cp from 'child_process'
import path from 'path'
import Bluebird from 'bluebird'
import Debug from 'debug'
import util from '../util'
import state from '../tasks/state'
import xvfb from './xvfb'
import { needsSandbox } from '../tasks/verify'
import { throwFormErrorText, getError, errors } from '../errors'
import readline from 'readline'
import { stdin, stdout, stderr } from 'process'

const debug = Debug('cypress:cli')

const DBUS_ERROR_PATTERN = /ERROR:dbus\/(bus|object_proxy)\.cc/

function isPlatform (platform: string): boolean {
  return os.platform() === platform
}

function needsStderrPiped (needsXvfb: boolean): boolean {
  return _.some([
    isPlatform('darwin'),

    (needsXvfb && isPlatform('linux')),

    util.isPossibleLinuxWithIncorrectDisplay(),
  ])
}

function needsEverythingPipedDirectly (): boolean {
  return isPlatform('win32')
}

function getStdioStrategy (needsXvfb: boolean): string | string[] {
  if (needsEverythingPipedDirectly()) {
    return 'pipe'
  }

  // https://github.com/cypress-io/cypress/issues/921
  // https://github.com/cypress-io/cypress/issues/1143
  // https://github.com/cypress-io/cypress/issues/1745
  if (needsStderrPiped(needsXvfb)) {
    // returning pipe here so we can massage stderr
    // and remove garbage from Xlib and libuv
    // due to starting the Xvfb process on linux
    return ['inherit', 'inherit', 'pipe']
  }

  return 'inherit'
}

function createSpawnFunction (
  executable: string,
  args: string[],
  options: any,
) {
  return (overrides: any = {}): any => {
    return new Bluebird((resolve: any, reject: any) => {
      _.defaults(overrides, {
        onStderrData: false,
      })

      const { onStderrData } = overrides
      const envOverrides = util.getEnvOverrides(options)
      const electronArgs: string[] = []
      const node11WindowsFix = isPlatform('win32')

      let startScriptPath: string | undefined

      if (options.dev) {
        executable = 'node'
        // if we're in dev then reset
        // the launch cmd to be 'npm run dev'
        startScriptPath = path.resolve(__dirname, '..', '..', '..', 'scripts', 'start.js')

        debug('in dev mode the args became %o', args)
      }

      if (!options.dev && needsSandbox()) {
        electronArgs.push('--no-sandbox')
      }

      // strip dev out of child process options
      /**
         * @type {import('child_process').ForkOptions}
         */
      let stdioOptions: any = _.pick(options, 'env', 'detached', 'stdio')

      // figure out if we're going to be force enabling or disabling colors.
      // also figure out whether we should force stdout and stderr into thinking
      // it is a tty as opposed to a pipe.
      stdioOptions.env = _.extend({}, stdioOptions.env, envOverrides)

      if (node11WindowsFix) {
        stdioOptions = _.extend({}, stdioOptions, { windowsHide: false })
      }

      if (util.isPossibleLinuxWithIncorrectDisplay()) {
        // make sure we use the latest DISPLAY variable if any
        debug('passing DISPLAY', process.env.DISPLAY)
        stdioOptions.env.DISPLAY = process.env.DISPLAY
      }

      if (stdioOptions.env.ELECTRON_RUN_AS_NODE) {
        // Since we are running electron as node, we need to add an entry point file.
        startScriptPath = path.join(state.getBinaryPkgPath(path.dirname(executable)), '..', 'index.js')
      } else {
        // Start arguments with "--" so Electron knows these are OUR
        // arguments and does not try to sanitize them. Otherwise on Windows
        // an url in one of the arguments crashes it :(
        // https://github.com/cypress-io/cypress/issues/5466
        args = [...electronArgs, '--', ...args]
      }

      if (startScriptPath) {
        args.unshift(startScriptPath)
      }

      if (process.env.CYPRESS_INTERNAL_DEV_DEBUG) {
        args.unshift(process.env.CYPRESS_INTERNAL_DEV_DEBUG)
      }

      debug('spawn args %o %o', args, _.omit(stdioOptions, 'env'))
      debug('spawning Cypress with executable: %s', executable)

      const child = cp.spawn(executable, args, stdioOptions)

      function resolveOn (event: any): any {
        return async function (code: any, signal: any): Promise<any> {
          debug('child event fired %o', { event, code, signal })

          if (code === null) {
            const errorObject = errors.childProcessKilled(event, signal)

            const err = await getError(errorObject)

            return reject(err)
          }

          resolve(code)
        }
      }

      child.on('close', resolveOn('close'))
      child.on('exit', resolveOn('exit'))
      child.on('error', reject)

      if (isPlatform('win32')) {
        const rl = readline.createInterface({
          input: stdin,
          output: stdout,
        })

        // on windows, SIGINT does not propagate to the child process when ctrl+c is pressed
        // this makes sure all nested processes are closed(ex: firefox inside the server)
        rl.on('SIGINT', async function () {
          const kill = (await import('tree-kill')).default

          kill(child.pid as number, 'SIGINT')
        })
      }

      // if stdio options is set to 'pipe', then
      //   we should set up pipes:
      //  process STDIN (read stream) => child STDIN (writeable)
      //  child STDOUT => process STDOUT
      //  child STDERR => process STDERR with additional filtering
      if (child.stdin) {
        debug('piping process STDIN into child STDIN')
        stdin.pipe(child.stdin)
      }

      if (child.stdout) {
        debug('piping child STDOUT to process STDOUT')
        child.stdout.pipe(stdout)
      }

      // if this is defined then we are manually piping for linux
      // to filter out the garbage
      if (child.stderr) {
        debug('piping child STDERR to process STDERR')
        child.stderr.on('data', (data: any) => {
          const str = data.toString()

          // if we have a callback and this explicitly returns
          // false then bail
          if (onStderrData && onStderrData(str)) {
            return
          }

          if (str.match(DBUS_ERROR_PATTERN)) {
            debug(str)
          } else {
          // else pass it along!
            stderr.write(data)
          }
        })
      }

      // https://github.com/cypress-io/cypress/issues/1841
      // https://github.com/cypress-io/cypress/issues/5241
      // In some versions of node, it will throw on windows
      // when you close the parent process after piping
      // into the child process. unpiping does not seem
      // to have any effect. so we're just catching the
      // error here and not doing anything.
      stdin.on('error', (err: any) => {
        if (['EPIPE', 'ENOTCONN'].includes(err.code)) {
          return
        }

        throw err
      })

      if (stdioOptions.detached) {
        child.unref()
      }
    })
  }
}

async function spawnInXvfb (spawn: ReturnType<typeof createSpawnFunction>): Promise<number> {
  try {
    await xvfb.start()

    const code = await userFriendlySpawn(spawn)

    return code
  } finally {
    await xvfb.stop()
  }
}

async function userFriendlySpawn (spawn: ReturnType<typeof createSpawnFunction>, linuxWithDisplayEnv?: any): Promise<any> {
  debug('spawning, should retry on display problem?', Boolean(linuxWithDisplayEnv))

  let brokenGtkDisplay: boolean = false

  const overrides: any = {}

  if (linuxWithDisplayEnv) {
    _.extend(overrides, {
      electronLogging: true,
      onStderrData (str: string): any {
        // if we receive a broken pipe anywhere
        // then we know that's why cypress exited early
        if (util.isBrokenGtkDisplay(str)) {
          brokenGtkDisplay = true
        }
      },
    })
  }

  try {
    const code: number = await spawn(overrides)

    if (code !== 0 && brokenGtkDisplay) {
      util.logBrokenGtkDisplayWarning()

      return spawnInXvfb(spawn)
    }

    return code
  } catch (error: any) {
    // we can format and handle an error message from the code above
    // prevent wrapping error again by using "known: undefined" filter
    if ((error as any).known === undefined) {
      const raiseErrorFn = throwFormErrorText(errors.unexpected)

      await raiseErrorFn(error.message)
    }

    throw error
  }
}

interface StartOptions {
  dev?: boolean
  env?: Record<string, string | undefined>
  detached?: boolean
  stdio?: string | string[]
}

export async function start (args: string | string[], options: StartOptions = {}): Promise<any> {
  let executable = util.getEnv('CYPRESS_RUN_BINARY') ?
    path.resolve(util.getEnv('CYPRESS_RUN_BINARY') as string) :
    state.getPathToExecutable(state.getBinaryDir())

  // Always push cwd into the args
  // which additionally acts as a signal to the
  // binary that it was invoked through the NPM module
  const baseArgs = args ? (typeof args === 'string' ? [args] : args) : []
  const decoratedArgs = baseArgs.concat([
    '--cwd', process.cwd(),
    '--userNodePath', process.execPath,
    '--userNodeVersion', process.versions.node,
  ])

  const needsXvfb = xvfb.isNeeded()

  debug('needs to start own Xvfb?', needsXvfb)

  const stdio = options.stdio ?? getStdioStrategy(needsXvfb)
  const dev = options.dev ?? false
  const detached = options.detached ?? false
  const env = options.env ?? process.env

  const spawn = createSpawnFunction(executable, decoratedArgs, { stdio, dev, detached, env })

  if (needsXvfb) {
    return spawnInXvfb(spawn)
  }

  // if we are on linux and there's already a DISPLAY
  // set, then we may need to rerun cypress after
  // spawning our own Xvfb server
  const linuxWithDisplayEnv = util.isPossibleLinuxWithIncorrectDisplay()

  return userFriendlySpawn(spawn, linuxWithDisplayEnv)
}
